A well maintained AD topology is very important because domain joined clients use this information to locate the optimal Domain Controller (DCLocator documentation here) – failing to find the most suitable domain controller will have performance impact on client side (slow logon, group policy processing, etc.). In an ideal world, when a new subnet is created and AD joined computers are placed here, AD admins are notified and they assign the subnet to the appropriate site – but sometimes this is not the case.
There are several methods to detect IP addresses coming from unassinged subnets:
– By analyzing the \\<dc>\admin$\debug\netlogon.log logfiles (example here)
– Looking for 5778 EventID in System log (idea from here)
– Using Powershell get all client registered DNS entries and look up against the replication subnets (some IP subnet calculator will be needed)
My idea was to use Defender for Identity logs (mainly because I recently (re)discovered the ipv4_lookup plugin in Kusto 🙃).
TL;DR
– by defining the ADReplicationSubnets as a datatable, we can find logon events from the IdentityLogonEvents table where clients use an IP address that is not in any replication subnet
– we can use a “static” datatable, or schedule a PowerShell script which will dynamically populate the items in this table
The query:
let IP_Data = datatable(network:string)
[
"10.0.1.0/24", //example subnet1
"10.0.2.0/24", //example subnet2
"192.168.0.0/16", //example subnet3
];
IdentityLogonEvents
| where ActionType == @"LogonSuccess"
| where Protocol == @"Kerberos"
| summarize LogonCount=dcount(Timestamp) by IPAddress,DeviceName
| evaluate ipv4_lookup(IP_Data, IPAddress, network, return_unmatched = true)
| where isempty( network)
Quite simple, isn’t it? So we filter for successful Kerberos logon events (without Protocol filter, other logon events could generate noise) and use the ipv4_lookup function to look up the IP address in the “IP_Data” variable’s “network” column, including those entries that cannot be matched with any subnet – then filter for the unmatched entries.
Scheduling the query as a PowerShell script
So far, so good. But over time, the list of subnets may change, grow, etc. – how can this subnet list be dynamically populated? Using the Get-ADReplicationSubnet command for example. As a prerequisite I created an app registration with ThreatHunting.Read.All application permission (with a certificate as credential):
The following script is used:
#required scope: ThreatHunting.Read.All
##Connect Microsoft Graph using Certauth
$tenantID = '<tenantID>'
$clientID = '<clientID>'
$certThumbprint = "<certThumbprint>"
Connect-MgGraph -TenantId $tenantID -ClientId $clientID -CertificateThumbprint $certThumbprint
##Define hunting query
$huntingQuery = '
let IP_Data = datatable(network:string)
['+( (Get-ADReplicationSubnet -filter *).Name | % {'"' + $_ + '",'}) +'
];
IdentityLogonEvents
| where ActionType == @"LogonSuccess"
| where Protocol == @"Kerberos"
| summarize LogonCount=dcount(Timestamp) by IPAddress,DeviceName
| evaluate ipv4_lookup(IP_Data, IPAddress, network, return_unmatched = true)
| where isempty( network)
'
#construct payload with 7 days timespan
$body = @{Query = $huntingQuery
Timespan = "P7D"
} | ConvertTo-Json
$url = "https://graph.microsoft.com/v1.0/security/runHuntingQuery"
#Run hunting query
$response = Invoke-MgGraphRequest -Method Post -Uri $url -Body $body
$results = foreach ($result in $response.results){
[pscustomobject]@{
IPAddress = $result.IpAddress
DeviceName = $result.DeviceName
LogonCount = $result.LogonCount
}
}
$results
The hunting query is the same as above, but the datatable entries are populated by the results of the Get-ADReplicationSubnet command (and some dirty string formatting like adding quotation marks and a column). In the $body variable the Timespan is set to seven days (ISO 8601 format) – when Timespan is not set, it defaults to 30 days (reference)
From this point, it is up to you to schedule the script (or fine tune the output) and email the results. 😊
Extra hint: if you have a multi-domain environment, the hunting query may need to be “domain specific” – for this purpose I would insert the following filter: | where AdditionalFields.Spns == “krbtgt/<domainDNSName>”, for example:
IdentityLogonEvents
| where ActionType == @"LogonSuccess"
| where Protocol == @"Kerberos"
| where AdditionalFields.Spns == "krbtgt/F12.HU"
| summarize LogonCount=dcount(Timestamp) by IPAddress,DeviceName
| evaluate ipv4_lookup(IP_Data, IPAddress, network, return_unmatched = true)
| where isempty( network)